🎖️GitЯра🎖️
Commit 3796787cdfdf741db05a2d48aba32bc32275925b
Parents : 2df817d
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-17T17:37:51Z
Committer : GitHub <noreply@github.com>
Date : 2026-08-17T17:37:51Z
fix(database): raise default cache limit and warn before eviction (#6742)
Changes
12 files changed, 166 insertions(+), 43 deletions(-)
Diff
diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 58cde04ca0..ee595cc2ec 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -151,6 +151,7 @@ broadcast_interval
busy_noise_floor
button_gpio
buzzer_gpio
+cache_limit_eviction_warning
calculating
call_sign
call_sign_summary
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/database/DatabaseManager.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/database/DatabaseManager.kt
index e99769a023..2a04fe5e5f 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/database/DatabaseManager.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/database/DatabaseManager.kt
@@ -29,6 +29,9 @@ interface DatabaseManager {
/** Sets the database cache limit. */
fun setCacheLimit(limit: Int)
+ /** Returns how many device-specific databases are currently cached on disk, subject to eviction. */
+ suspend fun cachedDeviceDbCount(): Int
+
/** Switches the active database to the one associated with the given [address]. */
suspend fun switchActiveDatabase(address: String?)
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt
index e9d10b09fb..da66d9a317 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt
@@ -26,7 +26,12 @@ object DatabaseConstants {
const val DEFAULT_DB_NAME: String = "${DB_PREFIX}_default"
const val CACHE_LIMIT_KEY: String = "node_db_cache_limit"
- const val DEFAULT_CACHE_LIMIT: Int = 3
+
+ // 3 was too aggressive for users who regularly rotate between more than a couple of radios: the LRU
+ // eviction in DatabaseManager.enforceCacheLimit() silently deletes a device's local chat history once
+ // it falls out of the cache. 5 gives more headroom while still bounding on-disk growth; MAX_CACHE_LIMIT
+ // remains the escape hatch for users who need more (github.com/meshtastic/Meshtastic-Android/issues/6186).
+ const val DEFAULT_CACHE_LIMIT: Int = 5
const val MIN_CACHE_LIMIT: Int = 1
const val MAX_CACHE_LIMIT: Int = 10
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
index d5857b075c..9279f478bd 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
@@ -1672,24 +1672,34 @@ open class DatabaseManager(private val datastore: DatabaseDataStore, private val
.toList()
}
+ /**
+ * Device-specific DB names, excluding retired/detached/legacy/default pools. Must be called while holding [mutex].
+ * A detached pool is still live for a consumer of an earlier [currentDb] emission, so its files must remain
+ * protected until orderly shutdown.
+ */
+ private fun deviceDbNamesLocked(): List<String> {
+ val detachedDbNames = detachedDatabases.mapTo(mutableSetOf()) { it.dbName }
+ return listExistingDbNames().filterNot {
+ it in logicallyRetired ||
+ it in detachedDbNames ||
+ it == DatabaseConstants.LEGACY_DB_NAME ||
+ it == DatabaseConstants.DEFAULT_DB_NAME
+ }
+ }
+
+ override suspend fun cachedDeviceDbCount(): Int = withManagerOperation {
+ withContext(dispatchers.io) { mutex.withLock { deviceDbNamesLocked().size } }
+ }
+
private suspend fun enforceCacheLimit() = withManagerOperation {
mutex.withLock {
// Deferred enforcement can wait behind a later switch. Resolve the protected name under the same mutex
// that publishes currentDbName so the active database at execution time can never become an LRU victim.
val activeDbName = currentDbName
val limit = getCurrentCacheLimit()
- val all = listExistingDbNames()
val pendingRouteNames = pendingRouteDbNames(datastore.data.first())
- val detachedDbNames = detachedDatabases.mapTo(mutableSetOf()) { it.dbName }
- // Only enforce the limit over device-specific DBs. A detached pool is still live for a consumer of an
- // earlier currentDb emission, so its files must remain protected until orderly shutdown.
- val deviceDbs =
- all.filterNot {
- it in logicallyRetired ||
- it in detachedDbNames ||
- it == DatabaseConstants.LEGACY_DB_NAME ||
- it == DatabaseConstants.DEFAULT_DB_NAME
- }
+ // Only enforce the limit over device-specific DBs.
+ val deviceDbs = deviceDbNamesLocked()
if (deviceDbs.size <= limit) return@withLock
val usageSnapshot = deviceDbs.associateWith { lastUsed(it) }
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 5ed60b798b..0ce7f4c8a3 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -172,6 +172,10 @@
<string name="busy_noise_floor">Busy floor</string>
<string name="button_gpio">Button GPIO</string>
<string name="buzzer_gpio">Buzzer GPIO</string>
+ <plurals name="cache_limit_eviction_warning">
+ <item quantity="one">Lowering this will permanently delete the saved history for %1$d device.</item>
+ <item quantity="other">Lowering this will permanently delete the saved history for %1$d devices.</item>
+ </plurals>
<string name="calculating">Calculating…</string>
<string name="call_sign">Call sign</string>
<string name="call_sign_summary">Your amateur radio call sign, up to 8 characters</string>
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDatabaseManager.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDatabaseManager.kt
index 74b825cdaf..b7e2b8a026 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDatabaseManager.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDatabaseManager.kt
@@ -69,6 +69,8 @@ class FakeDatabaseManager :
override fun hasDatabaseFor(address: String?): Boolean = address != null && existingDatabases.contains(address)
+ override suspend fun cachedDeviceDbCount(): Int = existingDatabases.size
+
companion object {
private const val DEFAULT_CACHE_LIMIT = 100
}
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
index c3c94e312b..8fe3e83d1f 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
@@ -266,6 +266,7 @@ fun SettingsScreen(
)
PersistenceSettingsContent(
cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value,
+ onCheckCacheLimitEvictionCount = { settingsViewModel.cachedDeviceCountExceeding(it) },
onSetCacheLimit = { settingsViewModel.setDbCacheLimit(it) },
nodeShortName = ourNode?.user?.short_name ?: "",
onExportData = { settingsViewModel.saveDataCsv(it.toKmpUri()) },
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/PersistenceSection.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/PersistenceSection.kt
index 0a511a8281..147465824a 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/PersistenceSection.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/PersistenceSection.kt
@@ -22,7 +22,6 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
import androidx.compose.ui.tooling.preview.Preview
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
@@ -32,15 +31,11 @@ import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.app_settings
-import org.meshtastic.core.resources.device_db_cache_limit
-import org.meshtastic.core.resources.device_db_cache_limit_summary
import org.meshtastic.core.resources.export_data_csv
import org.meshtastic.core.resources.export_node_db
import org.meshtastic.core.resources.save_rangetest
-import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Output
@@ -63,6 +58,7 @@ private val EXPORT_TIMESTAMP_FORMAT =
@Composable
internal fun ColumnScope.PersistenceSettingsContent(
cacheLimit: Int,
+ onCheckCacheLimitEvictionCount: suspend (Int) -> Int,
onSetCacheLimit: (Int) -> Unit,
nodeShortName: String,
onExportData: (android.net.Uri) -> Unit,
@@ -87,16 +83,10 @@ internal fun ColumnScope.PersistenceSettingsContent(
}
}
- val cacheItems = remember {
- (DatabaseConstants.MIN_CACHE_LIMIT..DatabaseConstants.MAX_CACHE_LIMIT).map { it.toLong() to it.toString() }
- }
- DropDownPreference(
- title = stringResource(Res.string.device_db_cache_limit),
- enabled = true,
- items = cacheItems,
- selectedItem = cacheLimit.toLong(),
- onItemSelected = { selected -> onSetCacheLimit(selected.toInt()) },
- summary = stringResource(Res.string.device_db_cache_limit_summary),
+ CacheLimitPreference(
+ cacheLimit = cacheLimit,
+ onCheckEvictionCount = onCheckCacheLimitEvictionCount,
+ onSetCacheLimit = onSetCacheLimit,
)
ListItem(
@@ -149,6 +139,7 @@ fun PersistenceSectionPreview() {
ExpressiveSection(title = stringResource(Res.string.app_settings)) {
PersistenceSettingsContent(
cacheLimit = 100,
+ onCheckCacheLimitEvictionCount = { 0 },
onSetCacheLimit = {},
nodeShortName = "TEST",
onExportData = {},
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
index c14310c61d..e3a84d83f9 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
@@ -117,6 +117,10 @@ class SettingsViewModel(
databaseManager.setCacheLimit(limit)
}
+ /** How many currently-cached device databases would be evicted if the cache limit were lowered to [limit]. */
+ suspend fun cachedDeviceCountExceeding(limit: Int): Int =
+ (databaseManager.cachedDeviceDbCount() - limit).coerceAtLeast(0)
+
// Notifications
val messagesEnabled = notificationPrefs.messagesEnabled
val nodeEventsEnabled = notificationPrefs.nodeEventsEnabled
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/CacheLimitPreference.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/CacheLimitPreference.kt
new file mode 100644
index 0000000000..fbe37848d0
--- /dev/null
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/CacheLimitPreference.kt
@@ -0,0 +1,101 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.settings.component
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.pluralStringResource
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.database.DatabaseConstants
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.apply
+import org.meshtastic.core.resources.are_you_sure
+import org.meshtastic.core.resources.cache_limit_eviction_warning
+import org.meshtastic.core.resources.cancel
+import org.meshtastic.core.resources.device_db_cache_limit
+import org.meshtastic.core.resources.device_db_cache_limit_summary
+import org.meshtastic.core.ui.component.DropDownPreference
+import org.meshtastic.core.ui.component.MeshtasticTextDialog
+
+/**
+ * Device DB cache limit dropdown, shared between the Android and Desktop settings screens. Lowering the limit below the
+ * number of currently-cached device databases evicts the least-recently-used ones and permanently deletes their local
+ * history, so a lower value is gated behind a confirmation showing how many devices would be affected. Raising or
+ * leaving the limit unchanged never evicts anything and applies immediately.
+ */
+@Composable
+fun CacheLimitPreference(cacheLimit: Int, onCheckEvictionCount: suspend (Int) -> Int, onSetCacheLimit: (Int) -> Unit) {
+ val cacheItems = remember {
+ (DatabaseConstants.MIN_CACHE_LIMIT..DatabaseConstants.MAX_CACHE_LIMIT).map { it.toLong() to it.toString() }
+ }
+ val scope = rememberCoroutineScope()
+ var pendingLimit by rememberSaveable { mutableStateOf<Int?>(null) }
+ var pendingEvictionCount by rememberSaveable { mutableIntStateOf(0) }
+ var selectionRequest by remember { mutableIntStateOf(0) }
+
+ DropDownPreference(
+ title = stringResource(Res.string.device_db_cache_limit),
+ enabled = true,
+ items = cacheItems,
+ selectedItem = cacheLimit.toLong(),
+ onItemSelected = { selected ->
+ val request = ++selectionRequest
+ val newLimit = selected.toInt()
+ if (newLimit >= cacheLimit) {
+ onSetCacheLimit(newLimit)
+ } else {
+ scope.launch {
+ val evicted = onCheckEvictionCount(newLimit)
+ if (request != selectionRequest) return@launch
+ if (evicted > 0) {
+ pendingEvictionCount = evicted
+ pendingLimit = newLimit
+ } else {
+ onSetCacheLimit(newLimit)
+ }
+ }
+ }
+ },
+ summary = stringResource(Res.string.device_db_cache_limit_summary),
+ )
+
+ pendingLimit?.let { limit ->
+ MeshtasticTextDialog(
+ titleRes = Res.string.are_you_sure,
+ message =
+ pluralStringResource(
+ Res.plurals.cache_limit_eviction_warning,
+ pendingEvictionCount,
+ pendingEvictionCount,
+ ),
+ confirmTextRes = Res.string.apply,
+ dismissTextRes = Res.string.cancel,
+ onConfirm = {
+ onSetCacheLimit(limit)
+ pendingLimit = null
+ },
+ onDismiss = { pendingLimit = null },
+ )
+ }
+}
diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt
index 017e34d1eb..26fc7a0756 100644
--- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt
+++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt
@@ -369,4 +369,16 @@ class SettingsViewModelTest {
viewModel.setDbCacheLimit(200)
databaseManager.cacheLimit.value shouldBe 10 // Clamped to MAX_CACHE_LIMIT
}
+
+ @Test
+ fun `cachedDeviceCountExceeding is zero when nothing would be evicted`() = runTest {
+ databaseManager.existingDatabases.addAll(listOf("a", "b", "c"))
+ viewModel.cachedDeviceCountExceeding(5) shouldBe 0
+ }
+
+ @Test
+ fun `cachedDeviceCountExceeding counts devices past the new limit`() = runTest {
+ databaseManager.existingDatabases.addAll(listOf("a", "b", "c", "d", "e"))
+ viewModel.cachedDeviceCountExceeding(2) shouldBe 3
+ }
}
diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt
index ae5bc32b4d..fbefc5abfb 100644
--- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt
+++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt
@@ -37,7 +37,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.navigation.DiscoveryRoute
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoute
@@ -47,8 +46,6 @@ import org.meshtastic.core.resources.about
import org.meshtastic.core.resources.app_settings
import org.meshtastic.core.resources.app_version
import org.meshtastic.core.resources.bottom_nav_settings
-import org.meshtastic.core.resources.device_db_cache_limit
-import org.meshtastic.core.resources.device_db_cache_limit_summary
import org.meshtastic.core.resources.device_links
import org.meshtastic.core.resources.discovery_local_mesh
import org.meshtastic.core.resources.help_and_documentation
@@ -60,7 +57,6 @@ import org.meshtastic.core.resources.preferences_language
import org.meshtastic.core.resources.remotely_administrating
import org.meshtastic.core.resources.theme
import org.meshtastic.core.resources.wifi_devices
-import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
@@ -76,6 +72,7 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.PermScanWifi
import org.meshtastic.core.ui.icon.Wifi
import org.meshtastic.core.ui.util.rememberShowToastResource
+import org.meshtastic.feature.settings.component.CacheLimitPreference
import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.component.FullMessageTimestampsSetting
import org.meshtastic.feature.settings.component.HomoglyphSetting
@@ -201,18 +198,10 @@ fun DesktopSettingsScreen(
onToggle = { radioConfigViewModel.toggleHomoglyphCharactersEncodingEnabled() },
)
- val cacheItems = remember {
- (DatabaseConstants.MIN_CACHE_LIMIT..DatabaseConstants.MAX_CACHE_LIMIT).map {
- it.toLong() to it.toString()
- }
- }
- DropDownPreference(
- title = stringResource(Res.string.device_db_cache_limit),
- enabled = true,
- items = cacheItems,
- selectedItem = cacheLimit.toLong(),
- onItemSelected = { selected -> settingsViewModel.setDbCacheLimit(selected.toInt()) },
- summary = stringResource(Res.string.device_db_cache_limit_summary),
+ CacheLimitPreference(
+ cacheLimit = cacheLimit,
+ onCheckEvictionCount = { settingsViewModel.cachedDeviceCountExceeding(it) },
+ onSetCacheLimit = { settingsViewModel.setDbCacheLimit(it) },
)
}
Served by rngit 1.5.0 - Generated in 0.17s